import json
import math
import os
import re
import shutil
import subprocess
import tarfile
import time
import traceback
import zipfile
from datetime import datetime

import git
import requests
from lxml import etree

from ci_api import CiAPI
from db_utils import DbUtils
from utils import get_logger, create_bisect_table, send_email

cache_dir = r'D:\blue_caches'
base_dir = os.path.dirname(__file__)

upgrade_tool = os.path.join(base_dir, 'upgrade_tool.exe')
pytest = r'D:\blue_caches\autotest_env\Scripts\pytest.exe'
testcase_project = r'D:\gitee\rk3568_pretest'

log = get_logger()
db = DbUtils(log)
ci = CiAPI(log)

version_list = []
trigger_list = []
tag_list = []
cause_pr = ''

bisect_result = {}


def bisect_all_failed_testcases():
    success_builds = ci.request_daily_build()
    if not success_builds:
        log.info('没有拿到版本构建记录，无法二分')
        return
    # 取最新的版本
    latest_build = success_builds[0]
    build_time = latest_build['buildStartTime']
    test_data = latest_build['testData']
    log.info('最新的版本构建时间为{}'.format(build_time))
    if not test_data or not test_data[0]['testLog']:
        log.info('没有拿到当前版本自动化用例执行结果（可能原因：自动化测试正在运行中 or 自动化流水线运行失败），无法二分')
        return
    test_log = test_data[0]['testLog']
    test_result = db.query_pipeline_autotest_result(build_time)
    if test_result:
        log.info('数据库中查询到了当前版本的测试结果，说明该版本已经二分过，不再继续对该版本二分')
        return
    test_result = download_and_parse_testlog(build_time, test_log)

    # 找出当前版本中所有失败的且支持二分的用例
    supported_testcases = db.query_active_testcases()
    failed_testcases = []
    for testcase, result in test_result.items():
        if testcase in supported_testcases and result != 'passed':
            failed_testcases.append(testcase)
    log.info('当前版本失败的用例中可以二分的用例有{}'.format(failed_testcases))
    if not failed_testcases:
        log.info('该版本没有可以二分的用例')
        send_email(subject='{}-版本二分报告'.format(build_time), h2='该版本测试用例全部通过，不需要二分')
        return
    # 首先对失败的用例复测，如果还是失败，才二分，如果成功了说明可能是概率问题或者环境问题导致的，这种问题不二分
    failed_testcases = rerun_failed_testcases(latest_build, failed_testcases)
    if not failed_testcases:
        log.info('重跑后没有用例失败')
        send_email(subject='{}-版本二分报告'.format(build_time), h2=f'失败用例{failed_testcases}重跑后全部通过，说明失败用例可能是概率问题或者环境问题导致，不再继续二分')
        return

    if len(failed_testcases) > 10:
        log.info('该版本失败用例过多，可能由环境问题或者共性问题导致，建议手工二分')
        send_email(subject='{}-版本二分报告'.format(build_time), h2='该版本失败用例过多，可能由环境问题或者共性问题导致，建议手工二分，失败的用例有{}'.format(failed_testcases))
        return

    # 更新代码
    git_pull_testcases()
    # 二分前先删除上一次的二分报告以及压缩包
    try:
        shutil.rmtree(os.path.join(base_dir, 'bisect_report'))
    except:
        pass
    try:
        os.remove(os.path.join(base_dir, 'bisect_report.zip'))
    except:
        pass

    global bisect_result
    bisect_result = {}
    log.info('遍历失败用例，并逐个二分')
    for testcase in failed_testcases:
        global version_list, trigger_list, tag_list, cause_pr
        version_list = []
        trigger_list = []
        tag_list = []
        cause_pr = ''
        try:
            bisect_single_testcase(testcase, success_builds)
        except:
            log.error('二分异常，异常信息如下：')
            log.error(traceback.format_exc())
        finally:
            log.info('生成二分结果报告')
            try:
                create_bisect_table(testcase, version_list, trigger_list, tag_list, cause_pr)
            except:
                log.error(traceback.format_exc())
        bisect_result.update({testcase: cause_pr})
    log.info('发送二分报告邮件')
    send_email(subject='{}-版本二分报告'.format(build_time), h2='二分报告如下，详细报告请参见附件', body=bisect_result)


def rerun_failed_testcases(version, testcases):
    log.info('重跑失败用例')
    img_link = version['imgObsPath']
    img = download_img(img_link)
    log.info('镜像位置{}'.format(img))
    extract_path = decompress_img(img)
    log.info('镜像解压位置{}'.format(extract_path))
    upgrade_rk3568(extract_path)
    run_testcases(testcases)
    report = os.path.join(testcase_project, 'report', 'report.html')
    report_dict = parse_html_report(report)
    db.upload_pipeline_result(version['buildStartTime'], report_dict)
    refail_testcases = []
    for testcase, result in report_dict.items():
        if result != 'passed':
            refail_testcases.append(testcase)
    log.info('重跑后仍然失败的用例有{}'.format(refail_testcases))
    return refail_testcases


def git_pull_testcases():
    log.info('更新测试用例')
    try:
        # 打开本地仓库
        repo = git.Repo(testcase_project)

        # 获取当前分支
        current_branch = repo.active_branch
        log.info(f"当前分支: {current_branch}")

        # 拉取最新代码
        log.info("正在拉取最新代码...")
        origin = repo.remotes.origin
        origin.pull()
        log.info("代码更新完成！")
    except git.exc.InvalidGitRepositoryError:
        log.error(f"错误: {testcase_project} 不是有效的 Git 仓库。")
    except git.exc.NoSuchPathError:
        log.error(f"错误: 路径 {testcase_project} 不存在。")
    except Exception as e:
        log.error(f"更新代码时发生错误: {e}")


def bisect_single_testcase(testcase, versions):
    log.info('')
    log.info('***开始对用例{}进行二分***'.format(testcase))
    early, later = get_newly_success_version(testcase, versions)
    if early < 0:
        log.info('从最近的{}个版本中找不到该用例成功的记录，无法确定二分时间范围'.format(len(versions)))
        return
    trigger_start_time = versions[early]['buildStartTime']
    trigger_end_time = versions[later]['buildStartTime']
    log.info('用例{}最近一次测试pass版本为{}'.format(testcase, trigger_start_time))
    log.info('用例{}最近一次测试fail版本为{}'.format(testcase, trigger_end_time))
    db_record = db.query_latest_cause_pr(testcase)
    log.info('查询结果：{}'.format(db_record))
    # 替换时间信息中的- ，空格，:号
    if db_record and trigger_start_time <= re.sub('[- :]', '', db_record['merge_time']) <= trigger_end_time:
        log.info('该时间段内已有二分结果，不需要继续二分，问题pr:{}'.format(db_record))
        global cause_pr
        cause_pr = db_record['pr']
        return
    else:
        trigger_bisect(testcase, trigger_start_time, trigger_end_time)


def get_newly_success_version(testcase, versions):
    """
    对单个用例进行二分
    :param testcase: 用例
    :param versions: 历史版本列表
    :return:
    """
    global version_list
    version_list = versions
    log.info('找出{}用例最近一次执行成功的日构建版本'.format(testcase))
    early = -1
    later = 0
    for index, version in enumerate(versions):
        if index == 0:
            continue
        build_time = version['buildStartTime']
        report_dict = db.query_pipeline_autotest_result(build_time)
        # 如果数据库中没有查到该版本的测试报告则补上去
        if not report_dict:
            test_data = version['testData']
            # 如果有测试结果，将该版本的测试结果归档
            if test_data and test_data[0]['testLog']:
                test_log = test_data[0]['testLog']
                report_dict = download_and_parse_testlog(build_time, test_log)
        # 如果该版本没有这个用例的测试结果，说明要么没上传要么测试失败
        if testcase not in report_dict:
            report_dict = verify_package(version['imgObsPath'], testcase)
            db.upload_pipeline_result(build_time, report_dict)

        result = report_dict.get(testcase, '')
        version_list[index].update({'result': result})
        log.info('{}版本，{}用例执行结果：{}'.format(build_time, testcase, result))
        # 找到最近一次跑成功的记录则从这个时间点开始分
        if result == 'passed':
            early = index
            break
        else:
            later = index
    log.info('好坏版本索引区间，early={}, later={}'.format(early, later))
    return early, later


def download_and_parse_testlog(build_time, log_url):
    log.info('下载{}的版本测试报告{}并归档到数据库'.format(build_time, log_url))
    log_file = os.path.join(cache_dir, '{}.zip'.format(build_time))
    if os.path.exists(log_file):
        os.remove(log_file)
    rsp = requests.get(log_url, stream=True)
    time1 = time.time()
    with open(log_file, 'ab') as f:
        for chunk in rsp.iter_content(chunk_size=1024 * 1024):
            f.write(chunk)
    time2 = time.time()
    log.info('测试报告下载耗时{}s'.format(int(time2 - time1)))
    extract_to = os.path.join(cache_dir, build_time)
    with zipfile.ZipFile(log_file, 'r') as zf:
        zf.extractall(extract_to)
    report_path = os.path.join(extract_to, 'result', 'screenshot_result')
    if not os.path.exists(report_path):
        log.info('没有测试报告')
        return {}
    reports = sorted([f for f in os.listdir(report_path) if f.endswith('.html')])
    test_result = {}
    for report in reports:
        html = os.path.join(report_path, report)
        result = parse_html_report(html)
        test_result.update(result)
    db.upload_pipeline_result(build_time, test_result)
    return test_result


def trigger_bisect(testcase, start_time, end_time):
    log.info('***开始对用例{}进行调度时间二分***'.format(testcase))
    global trigger_list
    for i in range(3):
        trigger_list = collect_trigger_events(start_time, end_time)
        if trigger_list:
            break
        time.sleep(10)

    early = -1
    later = -1
    scope = list(range(len(trigger_list)))

    while scope:
        log.info('调度时间二分索引范围{}'.format(scope))
        index = scope[len(scope) // 2]
        log.info('验证第{}笔调度记录...'.format(index))
        try:
            report_dict = verify_package(trigger_list[index]['img_link'], testcase)
            result = report_dict.get(testcase, '')
            trigger_list[index].update({'result': result})
        except:
            trigger_list[index].update({'result': 'crashed'})
            log.info('第{}笔调度记录验证异常,异常信息：{}'.format(index, traceback.format_exc()))
            scope.remove(index)
            continue
        log.info('第{}笔调度记录测试结果：{}'.format(index, result))
        if result == 'passed':
            early = index
            for i in scope[::-1]:
                if i >= index:
                    scope.remove(i)
        else:
            later = index
            for i in scope[::-1]:
                if i <= index:
                    scope.remove(i)

    log.info('调度时间二分缩小到的索引范围early={}, later={}'.format(early, later))
    if early == later:
        log.info('无法缩小触发构建的时间范围，因为early==later=={}'.format(early))
        return
    if early == -1:
        tag_start_time = datetime.strptime(start_time, '%Y%m%d%H%M%S').strftime('%Y-%m-%d %H:%M:%S')
    else:
        tag_start_time = trigger_list[early]['trigger_time']
    if later == -1:
        tag_end_time = datetime.strptime(end_time, '%Y%m%d%H%M%S').strftime('%Y-%m-%d %H:%M:%S')
    else:
        tag_end_time = trigger_list[later]['trigger_time']

    log.info('tag二分的起始时间点{}'.format(tag_start_time))
    log.info('tag二分的截止时间点{}'.format(tag_end_time))
    tag_bisect(testcase, tag_start_time, tag_end_time)


def collect_trigger_events(start_time, end_time):
    """
    获取已合入的触发构建的pr记录
    :param start_time:
    :param end_time:
    :return:
    """
    log.info('获取{} ~ {}时间段内的调度记录'.format(start_time, end_time))
    page = 1
    trigger_events = []
    build_events = ci.request_trigger_events(start_time, end_time, page)
    ci.downloadUrl = build_events.get('downloadUrl', '')
    total_triggers = build_events.get('total', 0)
    events = build_events.get('events', [])
    event = choice_one_of_events(events)
    if event:
        trigger_events.append(event)
    if total_triggers <= 50:
        return trigger_events
    total_pages = math.ceil(total_triggers / 50) + 1
    for p_num in range(2, total_pages):
        build_events = ci.request_trigger_events(start_time, end_time, p_num)
        events = build_events.get('events', [])
        event = choice_one_of_events(events)
        if not event:
            continue
        trigger_events.append(event)
    log.info(trigger_events)
    return trigger_events


def choice_one_of_events(events):
    for event in events:
        if event.get('currentStatus') != 'merged':
            continue
        timestamp = event['timestamp']
        trigger_time = datetime.strptime(timestamp, '%Y%m%d%H%M%S').strftime('%Y-%m-%d %H:%M:%S')
        pr = event['prMsg'][0]['url']
        builds = event['builds']
        if not builds:
            continue
        for b in builds:
            if b.get('buildTarget') == ci.buildTarget and b['debug']['result'] == 'success':
                artifacts = b['debug']['Artifacts']
                img_link = ci.downloadUrl + artifacts + '/version/' + artifacts.replace('/', '-') + '-version-' + ci.buildTarget + '.tar.gz'
                return {
                    'trigger_time': trigger_time,
                    'pr': pr,
                    'img_link': img_link
                }
    return {}


def tag_bisect(testcase, start_time, end_time):
    log.info('***开始对用例{}进行pr二分***'.format(testcase))
    global tag_list, cause_pr
    for i in range(3):
        tag_list = collect_tag(start_time, end_time)
        if tag_list:
            break
        time.sleep(10)

    perhaps_cause_repo = db.query_repo_probability(testcase)
    try:
        repo = next(perhaps_cause_repo)
    except StopIteration:
        repo = ''
    scope = list(range(len(tag_list)))

    cause_index = -1
    while scope:
        log.info('二分pr索引范围{}'.format(scope))
        # 找pr
        while True:
            if not repo:
                current = scope[len(scope) // 2]
                break
            else:
                find_flag = False
                for tag_index in scope[::-1]:
                    if repo in tag_list[tag_index]['pr']:
                        current = tag_index
                        find_flag = True
                        break
                else:
                    try:
                        repo = next(perhaps_cause_repo)
                    except StopIteration:
                        repo = ''
                if find_flag:
                    break
        try:
            log.info('验证第{}笔pr: {}'.format(current, tag_list[current]))
            report_dict = verify_package(tag_list[current]['version_url'], testcase)
            result = report_dict.get(testcase, '')
            tag_list[current].update({'result': result})
        except:
            tag_list[current].update({'result': 'crashed'})
            log.info('第{}笔pr验证异常,异常信息：{}'.format(current, traceback.format_exc()))
            scope.remove(current)
            continue

        if result == 'passed':
            log.info('第{}笔pr，用例执行成功'.format(current))
            # 循环删除必须从列表后往前删，所以要倒序删除
            for i in scope[:scope.index(current):-1]:
                if tag_list[i]['merge_time'] < tag_list[current]['build_time']:
                    log.info('排除索引为{}的pr，原因：合入时间在当前pr构建之前'.format(i))
                    scope.remove(i)
                else:
                    break
            scope.remove(current)
        else:
            log.info('第{}笔pr，用例执行失败'.format(current))
            for i in scope[::-1]:
                if tag_list[i]['merge_time'] > tag_list[current]['build_time'] and i != current:
                    log.info('排除索引为{}的pr，原因：合入时间在当前pr构建之后'.format(i))
                    scope.remove(i)
            scope.remove(current)
            # 最早一笔失败的即为定位到的pr
            cause_index = max(cause_index, current)

    if cause_index < 0:
        global trigger_list
        for trigger in trigger_list:
            if trigger['trigger_time'] == end_time:
                cause_pr = trigger['pr']
                log.info('定位到该问题可能由调度时间列表中的pr {}导致'.format(cause_pr))
                db.upload_bisect_result(testcase, trigger)
                break
        else:
            log.info('未定位到问题pr')
    else:
        cause_pr = tag_list[cause_index]['pr']
        log.info('定位到该问题可能由第{}笔pr导致{}'.format(cause_index, tag_list[cause_index]))
        db.upload_bisect_result(testcase, tag_list[cause_index])


def collect_tag(start_time, end_time):
    log.info('获取门禁tag系统中{} ~ {}时间段内的合入记录'.format(start_time, end_time))
    page = 1
    satisfy_tags = []
    rsp_data = ci.request_tag_build(start_time, end_time, page)
    total = rsp_data['data']['total']

    tags = rsp_data['data']['tagList']
    for tag in tags:
        tag_info = ci.filter_tag(tag, start_time, end_time)
        if tag_info:
            satisfy_tags.append(tag_info)
    if total <= 50:
        return satisfy_tags

    pages = math.ceil(total / 50) + 1
    for page_num in range(2, pages + 1):
        rsp_data = ci.request_tag_build(start_time, end_time, page_num)
        tags = rsp_data['data']['tagList']
        for tag in tags:
            tag_info = ci.filter_tag(tag, start_time, end_time)
            if tag_info:
                satisfy_tags.append(tag_info)
    log.info(satisfy_tags)
    return satisfy_tags


def verify_package(img_link: str, testcase: str) -> dict:
    img = download_img(img_link)
    log.info('镜像位置{}'.format(img))
    extract_path = decompress_img(img)
    log.info('镜像解压位置{}'.format(extract_path))
    upgrade_rk3568(extract_path)
    run_testcases([testcase])
    report = os.path.join(testcase_project, 'report', 'report.html')
    report_dict = parse_html_report(report)
    if report_dict.get(testcase) == 'passed':
        return report_dict
    # 执行失败重启一下设备再测试一次
    log.info('测试失败，重启后重新验证一次')
    reboot_device()
    run_testcases([testcase])
    report_dict = parse_html_report(report)
    return report_dict


def parse_html_report(html):
    log.info('解析测试报告{}'.format(html))
    if not os.path.exists(html):
        return {}
    with open(html, 'r') as f:
        root = etree.HTML(f.read())
    elements = eval(root.xpath('//footer/div')[0].attrib['data-jsonblob'])['tests']
    summary = {}
    for k, v in elements.items():
        testcase = re.search('.*/(?P<testcase>.*).py', k, re.I).group('testcase')
        result = v[0].get('result').lower()
        summary.update({testcase: result})
    log.info('测试结果：{}'.format(summary))
    return summary


def download_img(img_link):
    pkg_size = ci.get_img_size(img_link)
    if pkg_size < 400 * 1024 * 1024:
        raise Exception(f'镜像大小{pkg_size} B<400M，跳过此镜像验证')
    img_name = img_link.split('/')[-1]
    save_file = os.path.join(cache_dir, img_name)
    if os.path.exists(save_file) and os.path.getsize(save_file) == pkg_size:
        log.info('镜像已存在，无需重复下载')
    else:
        save_file = ci.download_img(img_link, save_file)
    return save_file


def decompress_img(img_file: str):
    log.info('解压镜像')
    if not os.path.isfile(img_file) or not img_file.endswith('tar.gz'):
        raise Exception('镜像不存在，或者格式不对'.format(img_file))
    extract_path = img_file[:-7]
    if os.path.exists(extract_path) and os.listdir(extract_path):
        log.info('本地已有解压后的产物，无需重复解压')
    else:
        with tarfile.open(img_file) as tf:
            tf.extractall(extract_path)
    return extract_path


def upgrade_rk3568(img_folder):
    img_files = os.listdir(img_folder)
    if not {'MiniLoaderAll.bin', 'parameter.txt', 'uboot.img'}.issubset(set(img_files)):
        raise FileNotFoundError('缺少必要的刷机文件！')
    location_id = enter_loader_mode()
    if not location_id:
        raise Exception('没找到Loader设备，可能设备未连接或者状态异常！')
    log.info('>>>刷机')
    MiniLoaderAll_bin = os.path.join(img_folder, 'MiniLoaderAll.bin')
    output = execute_upgrade_tool_cmd(f'-s {location_id} UL "{MiniLoaderAll_bin}" -noreset')
    if 'Upgrade loader ok' not in output:
        raise Exception('MiniLoaderAll.bin烧录失败！')

    parameter_txt = os.path.join(img_folder, 'parameter.txt')
    output = execute_upgrade_tool_cmd(f'-s {location_id} DI -p "{parameter_txt}"')
    if 'Write gpt ok' not in output:
        raise Exception('parameter.txt烧录失败！')

    uboot_img = os.path.join(img_folder, 'uboot.img')
    output = execute_upgrade_tool_cmd(f'-s {location_id} DI -uboot "{uboot_img}" "{parameter_txt}"')
    if 'Download image ok' not in output:
        raise Exception('uboot.img烧录失败！')

    burn_cmds = [
        '-s {} DI -resource  "{}"'.format(location_id, os.path.join(img_folder, 'resource.img')),
        '-s {} DI -boot_linux  "{}"'.format(location_id, os.path.join(img_folder, 'boot_linux.img')),
        '-s {} DI -ramdisk  "{}"'.format(location_id, os.path.join(img_folder, 'ramdisk.img')),
        '-s {} DI -system  "{}"'.format(location_id, os.path.join(img_folder, 'system.img')),
        '-s {} DI -vendor  "{}"'.format(location_id, os.path.join(img_folder, 'vendor.img')),
        '-s {} DI -updater  "{}"'.format(location_id, os.path.join(img_folder, 'updater.img')),
        '-s {} DI -userdata  "{}"'.format(location_id, os.path.join(img_folder, 'userdata.img')),
    ]
    for cmd in burn_cmds:
        output = execute_upgrade_tool_cmd(cmd)
        if 'Download image ok' not in output:
            raise Exception('镜像烧录失败，失败命令{}'.format(cmd))

    log.info('img烧录完成，重启设备')
    time.sleep(1)
    execute_upgrade_tool_cmd(f'-s {location_id} RD')
    time.sleep(60)
    log.info('刷机成功！')


def run_testcases(testcases: list):
    log.info('验证测试用例{}'.format(testcases))
    cur_dir = os.getcwd()
    try:
        # 先把报告删了
        if os.path.exists(os.path.join(testcase_project, 'report')):
            try:
                shutil.rmtree(os.path.join(testcase_project, 'report'))
            except:
                pass
        sn = hdc_list_targets()
        os.chdir(testcase_project)
        json_config = os.path.join(testcase_project, 'testcases.json')
        configs = json.load(open(json_config, encoding='utf-8'))
        run_files = []
        for case in testcases:
            for case_dict in configs:
                if case in case_dict.get('case_file'):
                    case_file = case_dict.get('case_file')
                    run_files.append(case_file)
                    break
        if not run_files:
            raise Exception('未找到用例的执行文件')
        run_params = '"{}"  -vs --sn={} --html=./report/report.html --self-contained-html --capture=sys {}'.format(pytest, sn, ' '.join(run_files))
        log.info(run_params)
        p = subprocess.Popen(run_params, shell=True, stdout=subprocess.PIPE, encoding='utf-8', errors='ignore')
        while True:
            output = p.stdout.readline()
            if not output and p.poll() is not None:
                break
    except:
        log.info(traceback.format_exc())
    finally:
        os.chdir(cur_dir)


def hdc_list_targets():
    for i in range(5):
        output = execute_cmd('hdc list targets').strip()
        log.info(output)
        if 'Empty' in output:
            time.sleep(5)
            continue
        line = output.splitlines()
        if len(line) > 1:
            raise Exception('请确保只有一台RK3568设备处于连接状态')
        return line[0]
    return ''


def enter_loader_mode():
    log.info('>>>进入loader模式')
    for i in range(3):
        execute_cmd('hdc shell reboot loader')
        time.sleep(5)
        location_id = get_location_id()
        if location_id:
            return location_id
        time.sleep(2)
    return ''


def reboot_device():
    log.info('重启设备')
    execute_cmd('hdc shell reboot')
    time.sleep(60)


def execute_cmd(cmd):
    log.info(cmd)
    rst = subprocess.run(cmd, capture_output=True, shell=True, encoding='utf-8')
    out_put = rst.stdout or rst.stderr
    return out_put


def execute_upgrade_tool_cmd(cmd):
    upgrade_cmd = f'"{upgrade_tool}" {cmd}'
    return execute_cmd(upgrade_cmd)


def get_location_id():
    output = execute_upgrade_tool_cmd('LD')
    log.info(output)
    for line in output.splitlines():
        if 'LocationID' in line and 'Mode=Loader' in line:
            return re.search(r'LocationID=(?P<location_id>\d+)', line).group('location_id')
    return ''


if __name__ == '__main__':
    while True:
        try:
            bisect_all_failed_testcases()
            log.info('30min后查询最新测试结果')
            time.sleep(30*60)
        except:
            log.error('运行异常')
            log.error(traceback.format_exc())
            try:
                db.delete_latest_pipeline_result()
            except:
                pass
            time.sleep(60)
            send_email(subject='二分任务异常', h2=traceback.format_exc())
